Web Push notifications foundation (CIRCLE-38)#107
Conversation
Sets up the plumbing for browser-based push notifications without yet delivering any. Subsequent work hooks comment-reply/mention events into this pipeline. Engine - coplan_web_push_subscriptions table + model with per-device records (endpoint digest, keys, UA, last_seen_at, last_delivered_at, counter). - Idempotent upsert_for tolerant of concurrent insert races; atomic increment for delivery counters. - Service worker served from /coplan_service_worker.js (engine-scoped, no auth, no-cache, rendered inline so reverse proxies don't intercept). - Push handler shows notifications and routes notification clicks to a matching same-origin tab (preserves URL hash for anchor deep links). - POST/DELETE /web_push/subscription endpoints, scoped to current_user. - Shared coplan/web_push ES module (subscribe/unsubscribe/isSupported). - Settings card to enable/disable per-device + list of known devices with friendly labels (Chrome on macOS, Safari on iOS, etc.). - VAPID public key + service worker URL exposed via meta tags only when configured. Configuration gains vapid_public_key/vapid_private_key/ vapid_subject and web_push_configured?. - web-push gem dependency + bundle exec rake coplan:web_push:generate_keys. Host - Dev VAPID key pair wired into config/initializers/coplan.rb via ENV fallbacks so local Settings UI is testable out-of-the-box. Generated with Amp Amp-Thread-ID: https://ampcode.com/threads/T-019df459-b110-726a-97e2-ff15e2903435 Co-authored-by: Amp <amp@ampcode.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e7b7447ee9
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| # or your secrets manager. Generate fresh keys with: | ||
| # bundle exec rake coplan:web_push:generate_keys | ||
| config.vapid_public_key = ENV["COPLAN_VAPID_PUBLIC_KEY"] || "BPY5NsdGJ4vEmHHNz3SqK2XsmV93j-iR3-kqN-RMbl4JRd9jnKpzunwdXDwFwlzbRlPErn3x379e6Cz7DfdSS6o=" | ||
| config.vapid_private_key = ENV["COPLAN_VAPID_PRIVATE_KEY"] || "1HoYR1d8QIlf8RYTfugJQFTyLlBat3zd-EFkj5dO9WQ=" |
There was a problem hiding this comment.
Remove checked-in fallback VAPID private key
This initializer hardcodes a usable VAPID private key as a default, so any environment that forgets to set COPLAN_VAPID_PRIVATE_KEY will silently run with a publicly known signing key. In that state, anyone who learns a subscription endpoint can forge push messages that appear to come from this app. This should fail closed in non-development environments instead of defaulting to a committed secret.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Good catch — fixed in 3f2aa35. The fallback keypair now only applies in Rails.env.development?. In any other env, missing COPLAN_VAPID_* leaves the config nil, which makes web_push_configured? false and disables the meta tags / subscription endpoints / settings card entirely. Also rotated the dev keypair since the prior one is now in commit history.
| arm-linux-musl | ||
| arm64-darwin | ||
| x86_64-darwin | ||
| arm64-darwin-25 |
There was a problem hiding this comment.
Restore Intel macOS platform support in lockfile
The lockfile now lists only arm64-darwin-25 for macOS and drops x86_64-darwin, which makes Bundler treat Intel Macs as unsupported when using a frozen/deployment lockfile. That blocks bundle install for contributors or CI runners on Intel macOS until the platform is re-added to Gemfile.lock.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 3f2aa35 via bundle lock --add-platform x86_64-darwin arm64-darwin. Both are back in the lockfile so frozen bundle install works on Intel macOS again. (The original drop was an artifact of bundle install running on this arm64-darwin-25 machine.)
- Fail closed on missing VAPID keys outside development. The checked-in fallback keypair only applies in Rails.env.development?; production with unset COPLAN_VAPID_* simply disables web push (per web_push_configured?) instead of silently signing with a public key. Rotated the dev fallback keys since the previous values are now in commit history. - Restore arm64-darwin and x86_64-darwin in Gemfile.lock so Intel macOS contributors and CI can install with a frozen lockfile. Generated with Amp Amp-Thread-ID: https://ampcode.com/threads/T-019df459-b110-726a-97e2-ff15e2903435 Co-authored-by: Amp <amp@ampcode.com>
Foundation in #107 stops at "subscriptions are stored, SW is wired up, Settings card works." This commit lights it up — the server actually sends a push to every device a user has enabled, anchored to the Notification model so reply / new_comment / mention / agent_response / status_change all flow through the same path. Engine - CoPlan::WebPush::Deliver — wraps web-push gem; signs with VAPID; returns :delivered or :expired (404/410); re-raises transient errors so SolidQueue can retry. - CoPlan::WebPush::PayloadForNotification — builds {title,body,url,tag} per Notification; reason-aware phrasing; mention chips rewritten to @username; markdown emphasis stripped; body truncated to 140 chars with an ellipsis; hyphens and # preserved (regression test included) so co-worker / URL#fragment survive intact; URL is engine-relative so the SW resolves it against self.location.origin. - CoPlan::WebPushDeliveryJob — per-(notification, subscription) so a single bad endpoint doesn't block the user's other devices; destroys the subscription on :expired; retry_on PushServiceError / TooManyRequests up to 5 times with polynomial backoff; defensive against either record being deleted before the job runs. - Notification#after_commit on :create fans out one job per active subscription belonging to the recipient. Quietly no-ops when web_push_configured? is false (host hasn't set VAPID). Specs (+20 examples) - Deliver: 2xx records delivery, 410/404 returns :expired, 503 re-raises, ConfigurationError when VAPID is unset. - PayloadForNotification: per-reason titles, mention/markdown stripping, hyphen/URL preservation, truncation with ellipsis, anonymous fallback. - WebPushDeliveryJob: calls Deliver with the right args, destroys on :expired, no-ops on missing records. - Notification: fan-out enqueues one job per subscription on create, zero when no subscriptions, zero on update, zero when web push isn't configured. Full suite: 834 examples, 0 failures. Generated with Amp Amp-Thread-ID: https://ampcode.com/threads/T-019df459-b110-726a-97e2-ff15e2903435 Co-authored-by: Amp <amp@ampcode.com>
Foundation in #107 stops at "subscriptions are stored, SW is wired up, Settings card works." This commit lights it up — the server actually sends a push to every device a user has enabled, anchored to the Notification model so reply / new_comment / mention / agent_response / status_change all flow through the same path. Engine - CoPlan::WebPush::Deliver — wraps web-push gem; signs with VAPID; returns :delivered or :expired (404/410); re-raises transient errors so SolidQueue can retry. - CoPlan::WebPush::PayloadForNotification — builds {title,body,url,tag} per Notification; reason-aware phrasing; mention chips rewritten to @username; markdown emphasis stripped; body truncated to 140 chars with an ellipsis; hyphens and # preserved (regression test included) so co-worker / URL#fragment survive intact; URL is engine-relative so the SW resolves it against self.location.origin. - CoPlan::WebPushDeliveryJob — per-(notification, subscription) so a single bad endpoint doesn't block the user's other devices; destroys the subscription on :expired; retry_on PushServiceError / TooManyRequests up to 5 times with polynomial backoff; defensive against either record being deleted before the job runs. - Notification#after_commit on :create fans out one job per active subscription belonging to the recipient. Quietly no-ops when web_push_configured? is false (host hasn't set VAPID). Specs (+20 examples) - Deliver: 2xx records delivery, 410/404 returns :expired, 503 re-raises, ConfigurationError when VAPID is unset. - PayloadForNotification: per-reason titles, mention/markdown stripping, hyphen/URL preservation, truncation with ellipsis, anonymous fallback. - WebPushDeliveryJob: calls Deliver with the right args, destroys on :expired, no-ops on missing records. - Notification: fan-out enqueues one job per subscription on create, zero when no subscriptions, zero on update, zero when web push isn't configured. Full suite: 834 examples, 0 failures. Generated with Amp Amp-Thread-ID: https://ampcode.com/threads/T-019df459-b110-726a-97e2-ff15e2903435 Co-authored-by: Amp <amp@ampcode.com>
Foundation for in-browser push notifications. No notifications are delivered yet — that lands in follow-up work hooked into comment-reply / mention events.
Linear: CIRCLE-38
What's here
Per-device subscription storage
coplan_web_push_subscriptionstable (FK tocoplan_users) andCoPlan::WebPushSubscriptionmodel.upsert_foris idempotent and rescuesRecordNotUniquefor concurrent inserts;record_delivery!uses an atomicincrement!so concurrent deliveries can't lose updates.device_labelreturns friendly strings like "Chrome on macOS" / "Safari on iOS".Service worker
/coplan_service_worker.js(engine-mounted route), no auth,Cache-Control: no-cache. Rendered inline so reverse proxies that interceptX-Sendfilewon't try to reach into the gem on disk.Subscription endpoints
POST /web_push/subscription— upserts the browser subscription forcurrent_user.DELETE /web_push/subscription— scoped tocurrent_userso a leaked endpoint can't unsubscribe someone else.Browser-side JS
coplan/web_push.jsES module withisSupported / permission / isSubscribed / subscribe / unsubscribe. Pinned via importmap.navigator.serviceWorker.readyafterregister()so PushManager has an active worker (fixes the "no active Service Worker" failure on first subscribe).CoPlan.configuration.web_push_configured?.Settings UI
[hidden] { display: none !important; }sohiddenactually hides.btnelements.Configuration
CoPlan::Configurationgainsvapid_public_key / vapid_private_key / vapid_subjectandweb_push_configured?.bundle exec rake coplan:web_push:generate_keys.config/initializers/coplan.rbships a checked-in dev VAPID keypair behind ENV overrides so the Settings UI works out-of-the-box locally. Production should override via env / encrypted credentials.Verification
bundle exec rspec→ 814 examples, 0 failures.WebPushSubscriptionmodel, subscriptions controller (POST/DELETE), service worker route.Out of scope (next PRs)
web-pushgem + VAPID signing) wired into comment-reply / mention events.coplan-square.Generated with Amp